package com.simpou.commons.utils.tests;

import com.simpou.commons.utils.model.Pair;
import com.simpou.commons.utils.reflection.Annotations;
import com.simpou.commons.utils.reflection.Reflections;
import com.simpou.commons.utils.string.Strings;
import com.simpou.commons.utils.tests.annot.*;

import org.apache.commons.logging.Log;

import org.junit.Test;

import org.junit.runner.Description;

import org.junit.runners.model.InitializationError;

import java.lang.reflect.Method;
import java.lang.reflect.Modifier;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;


/**
 * Executa ações utilizadas por...
 *
 * @see com.simpou.util.test.TestRunner.
 *
 * @author Jonas Pereira
 * @since 2011-10-04
 * @version 2012-06-10
 */

//TODO tirar logs
//TODO documentar melhor
//TODO tirar logs
//TODO documentar melhor
class TestRunnerService {
    /**
     * Mapeia o número repetições de cada método.
     */
    private final Map<String, Integer> mapTimes = new HashMap<String, Integer>();

    /**
     * Mapeia a ordem de execução de cada método.
     */
    private final Map<String, Integer> mapOrder = new HashMap<String, Integer>();

    /**
     * Mapeia a ordem de execução de cada método. São removidos conforme
     * utilizados.
     */
    private final Map<String, Integer> mapOrderLasts = new HashMap<String, Integer>();

    /**
     * Define quais métodos já foram executados. Por algum motivo a descrição é
     * solicitada múltiplas vezes antes e depois da execução.
     */
    private final Set<String> runneds = new HashSet<String>();

    /**
     * Mapeia o número de execuções já realizadas de cada método.
     */
    private final Map<String, Integer> mapTimesCount = new HashMap<String, Integer>();

    /**
     * Logger.
     */
    private final Log logger;

    /**
     * Classe de teste.
     */
    private final Class<?> testClass;

    /**
     * Número de repetições definidas na classe de teste.
     */
    private int classRepeats;

    /**
     * Define se anotação para ignorar validações está presente.
     */
    private final boolean isIgnoreValidationPresent;

    /**
     * @param testClass Classe de teste.
     * @param logger Logger.
     */
    TestRunnerService(final Class<?> testClass, final Log logger) {
        this.testClass = testClass;
        this.logger = logger;

        // Verifica se as validações devem ser ignoradas.
        IgnoreValidation ignore = testClass.getAnnotation(IgnoreValidation.class);

        if (ignore != null) {
            logger.debug("Class validations is ignored.");
            isIgnoreValidationPresent = true;
        } else {
            isIgnoreValidationPresent = false;
        }
    }

    /**
     * Realiza validações na classe de teste, na classe a ser testada e nas
     * relações entre elas.
     *
     * @throws InitializationError InitializationError.
     */
    void checkClass() throws InitializationError {
        // verifica anotações obrigatórias
        if (!isIgnoreValidationPresent) {
            if (!Annotations.isAnnotatedWith(testClass, ClassTest.class)) {
                throw new InitializationError("Annotation required: " +
                    ClassTest.class.getName());
            }

            // a classe e seu teste devem estar no mesmo pacote
            ClassTest classTest = testClass.getAnnotation(ClassTest.class);
            // o nome da classe de teste deve iniciar pelo nome da classe a ser testada
            {
                final String subClassFlag = "$";
                final Class<?> classTestValue = classTest.value();
                String classTestName;

                if (classTestValue.getName().contains(subClassFlag)) {
                    // é uma subclasse que está sendo testada
                    final String fullClassName = classTestValue.getName();
                    classTestName = fullClassName.substring(fullClassName.lastIndexOf(
                                ".") + 1, fullClassName.indexOf(subClassFlag));
                } else {
                    classTestName = classTestValue.getSimpleName();
                }

                if (!testClass.getSimpleName().startsWith(classTestName)) {
                    throw new InitializationError("Test " + testClass +
                        " name must starts with " + classTest.value() +
                        " name.");
                }
            }

            if (!classTest.value().getPackage().equals(testClass.getPackage())) {
                throw new InitializationError(
                    "Class and test class must be in the same package.");
            }
        }

        // verifica número de repetições válido
        classRepeats = getTestClassRepeats();

        if (classRepeats < 1) {
            throw new InitializationError(
                "Class repeats must be greater than zero.");
        }
    }

    /**
     * Verifica se todos os métodos visíveis e implementados da classe testada
     * estão presentes na classe de teste. Os nomes dos métodos de teste devem
     * começar com test seguido com o nome do método a ser testado com a
     * primeira letra em maiúsculo. Para testar métodos com nomes iguais mas
     * assinaturas diferentes use o caracter '_' após o nome mais algum
     * identificador. Na classe testada, métodos com nomes iguais não são
     * considerados iguais, exceto se a classe for genérica.
     *
     * @throws InitializationError Validação mal sucedida.
     */
    void checkMethods() throws InitializationError {
        // lista métodos da classe de teste
        Map<String, Integer> methodTestCount = getMethodTestCount();

        if (isIgnoreValidationPresent) {
            return;
        }

        // obtém classe a ser testada
        Class classTest = testClass.getAnnotation(ClassTest.class).value();

        // lista métodos da classe a ser testada
        Map<String, Integer> methodClassCount = methodClassCount(classTest);

        // verifica se todos métodos da classe a ser testada é de fato testada
        // na classe de teste
        Pair<StringBuilder, Boolean> checkResult = checkTestMethods(classTest,
                methodTestCount, methodClassCount);
        Pair<StringBuilder, Boolean> checkResultExtra = checkExtraTestMethods(methodTestCount,
                methodClassCount);

        final String sepBar = "\n----------------------------------------\n";
        StringBuilder reportBuffer = new StringBuilder("Check report: " +
                sepBar);
        reportBuffer.append("Check class methods report:");
        reportBuffer.append("\n\n");
        reportBuffer.append("Status\tDescription\n");
        reportBuffer.append(checkResult.getKey());
        reportBuffer.append(checkResultExtra.getKey());

        // reporta resultados
        reportBuffer.append(sepBar);

        if (checkResult.getValue() || checkResultExtra.getValue()) {
            logger.warn(reportBuffer);

            if (!isIgnoreValidationPresent) {
                throw new InitializationError(
                    "\nProblem(s) found, see log reports.");
            }
        } else {
            logger.info(reportBuffer);
        }
    }

    /**
     * @return Número de repetições definidas na classe.
     */
    int getClassRepeats() {
        return classRepeats;
    }

    /**
     * @return Número de repetições definidas na classe.
     */
    private int getTestClassRepeats() {
        Repeat repeat = testClass.getAnnotation(Repeat.class);
        int repeats;

        if (repeat != null) {
            repeats = repeat.value();
        } else {
            repeats = 1;
        }

        return repeats;
    }

    /**
     * @param methodName Nome do método de teste.
     * @return Número de repetições do método.
     */
    Integer getMethodRepeatsByName(final String methodName) {
        return mapTimes.get(methodName);
    }

    /**
     * @param methodName Nome do método de teste.
     * @return Número de execuções já realizadas do método.
     */
    Integer getMethodRepeatsCountByName(final String methodName) {
        return mapTimesCount.get(methodName);
    }

    /**
     * Remove da lista o método consultado.
     *
     * @param methodName Nome do método de teste.
     * @return Ordem de execução do método.
     */
    Integer getMethodOrderByName(final String methodName) {
        Integer order = mapOrder.get(methodName);

        if (order != null) {
            mapOrderLasts.remove(methodName);
        }

        return order;
    }

    /**
     * @return true se houver algum método que define ordenação.
     */
    boolean hasOrder() {
        return !mapOrderLasts.isEmpty();
    }

    /**
     * @param methodName Nome do método de teste.
     * @return Se o método já executou.
     */
    boolean isRunned(final String methodName) {
        return runneds.contains(methodName);
    }

    /**
     * @param methodName Nome do método de teste.
     * @return Description.
     */
    Description getMethodDescription(final String methodName) {
        int repeats = getMethodRepeatsByName(methodName);

        // Por algum motivo, este método é chamado desordenadamente, daí a 
        // necessidade das listas e mapas de controle. Controle utilizado 
        // somente para bater a descrição da execução atual com o contador
        // de execuções.
        if (isRunned(methodName)) {
            mapTimesCount.put(methodName, mapTimesCount.get(methodName) + 1);
        }

        Integer timesCount = mapTimesCount.get(methodName);

        String descMsg = "";
        final Integer order = mapOrder.get(methodName);

        if (order != null) {
            descMsg += (order + ". ");
        }

        if (repeats > 1) {
            descMsg += ("[" + timesCount + "," + repeats + "] ");
        }

        descMsg += (testClass.getSimpleName() + "." + methodName);

        // Esta descrição aparece na lista de testes executados.
        // Para cada execução é mostrada a execução atual e o total de 
        // repetições.
        Description description = Description.createTestDescription(testClass,
                descMsg);

        return description;
    }

    /**
     * Sinaliza que o um método foi executado.
     *
     * @param methodName Nome do método de teste.
     */
    void sinalizeMethodExecution(final String methodName) {
        // sinaliza que o método executou e que pode incrementar contador de 
        // execuções
        runneds.add(methodName);
    }

    /**
     * @return Número de vezes que cada método foi definido na classe de teste.
     * @throws InitializationError Erro na validação.
     */
    private Map<String, Integer> getMethodTestCount()
        throws InitializationError {
        String methodName;
        Map<String, Integer> methodTestCount = new HashMap<String, Integer>();
        Method[] testMethods = testClass.getMethods();
        int repeats;

        for (Method method : testMethods) {
            // verifica visibilidade, os não acessíveis são desconsiderados
            if (!isTestMethod(method) || isMethodSynthetic(method) ||
                    !isMethodAcessible(method) ||
                    !Annotations.isAnnotatedWith(method, Test.class)) {
                continue;
            }

            methodName = method.getName();

            TestOrder orderAnnot = method.getAnnotation(TestOrder.class);

            if (orderAnnot != null) {
                Integer value = orderAnnot.value();
                mapOrder.put(methodName, value);
                mapOrderLasts.put(methodName, orderAnnot.value());
            }

            // Define número de repetições do método. A anotação de método
            // sobrescreve a de classe. 
            // Se nenhuma foi definida executa uma vez.
            // Se só a de classe foi definida, a mesma é utilizada.
            Repeat annot = method.getAnnotation(Repeat.class);

            if (annot != null) {
                repeats = annot.value();

                if (repeats < 1) {
                    throw new InitializationError(
                        "Method repeats must be greater than zero.");
                }
            } else {
                repeats = classRepeats;
            }

            mapTimes.put(methodName, repeats);
            mapTimesCount.put(methodName, 0);

            // desconsidera variações de métodos de mesmo nome
            if (methodName.contains("_")) {
                methodName = methodName.substring(0, methodName.indexOf("_"));
            }

            // conta número de vezes que cada método foi definido na classe
            Integer count = methodTestCount.get(methodName);

            // incrementa contador
            if (Annotations.isAnnotatedWith(method, ExtraTest.class)) {
                methodTestCount.put(methodName, 0);
            } else if (count == null) {
                methodTestCount.put(methodName, 1);
            } else {
                methodTestCount.put(methodName, ++count);
            }
        }

        return methodTestCount;
    }

    /**
     * @param method Método.
     * @return Se o método foi gerado pelo compilador e não é genérico.
     */
    private boolean isMethodSynthetic(final Method method) {
        return method.getName().startsWith("access$");
    }

    /**
     * @param classTest Classe a ser testada.
     * @return Número de vezes que cada método foi definido na classe a ser
     * testada. Somente métodos acessíveis são considerados.
     */
    private Map<String, Integer> methodClassCount(final Class<?> classTest) {
        Map<String, Integer> methodClassCount = new HashMap<String, Integer>();
        Method[] classMethods = classTest.getDeclaredMethods();

        // verifica se a classe de teste é uma interface que deve ser considerada
        boolean isIfaceTest = classTest.isInterface();

        // percorre métodos da classe a ser testada
        for (Method method : classMethods) {
            // verifica se o método foi gerado pelo compilador e não é genérico
            if (isMethodSynthetic(method)) {
                logger.debug("Synthetic method was found: " + method);

                continue;
            }

            // verifica visibilidade, os não acessíveis são desconsiderados
            if (!isIfaceTest && !isMethodAcessible(method)) {
                logger.debug("Inacessible method was ignored: " +
                    method.getName());

                continue;
            }

            // métodos duplicados por serem implementados de interfaces genéricas
            // causam um aumento incorreto na contagem
            if (method.isBridge()) {
                logger.debug("Bridge method was ignored: " + method.getName());

                continue;
            }

            // incrementa contador
            Integer count = methodClassCount.get(method.getName());

            if (count == null) {
                methodClassCount.put(method.getName(), 1);
            } else {
                methodClassCount.put(method.getName(), ++count);
            }
        }

        return methodClassCount;
    }

    /**
     * @param method Método.
     * @return Se é um método de teste.
     */
    private boolean isTestMethod(final Method method) {
        return method.getAnnotation(Test.class) != null;
    }

    /**
     * @param method Método.
     * @return Se o método não é abstrato, privado nem protegido.
     */
    private boolean isMethodAcessible(final Method method) {
        final int modifier = method.getModifiers();
        final boolean isAbstract = Modifier.isAbstract(modifier);
        final boolean isPrivate = Modifier.isPrivate(modifier);
        final boolean isProtected = Modifier.isProtected(modifier);
        //TODO achar uma forma de detectar isso de uma outra forma
        final boolean isCoberturaAddedMethod = method.getName().startsWith("__cobertura_");
        return !(isAbstract || isPrivate || isProtected || isCoberturaAddedMethod);
    }

    /**
     * @param methodTestCount Quantidades dos métodos de teste.
     * @param methodClassCount Quantidades dos métodos de a serem testados.
     * @return Checagem ok.
     */
    private Pair<StringBuilder, Boolean> checkExtraTestMethods(
        final Map<String, Integer> methodTestCount,
        final Map<String, Integer> methodClassCount) {
        boolean problemFound = false;
        String className;
        StringBuilder reportBuffer = new StringBuilder();
        int testCount;
        String methodTestName;

        for (Entry<String, Integer> entry : methodTestCount.entrySet()) {
            methodTestName = entry.getKey();
            className = methodTestName.substring(4);
            className = Strings.toLowerCaseFirstLetter(className).trim();
            testCount = entry.getValue();

            if ((testCount > 0) &&
                    !methodClassCount.keySet().contains(className)) {
                reportBuffer.append("EXTRA");
                reportBuffer.append(getReportRow(methodTestName, 0, testCount));
                problemFound = true;
            }
        }

        return new Pair<StringBuilder, Boolean>(reportBuffer, problemFound);
    }

    /**
     * @param methodName Nome do método.
     * @param expectedCount Número esperado de sobrecargas do método.
     * @param givenCount Número dado de sobrecargas do método.
     * @return Relatório de um método, define se está ok ou não e os números de
     * sobrecargas dado e esperado.
     */
    private StringBuilder getReportRow(final String methodName,
        final Integer expectedCount, final Integer givenCount) {
        return new StringBuilder("\tGiven: ").append(givenCount)
                                             .append(" Expected: ")
                                             .append(expectedCount)
                                             .append(" * Method: ")
                                             .append(methodName).append("\n");
    }

    /**
     * @param classTest Classe de teste.
     * @param methodTestCount Quantidades dos métodos de teste.
     * @param methodClassCount Quantidades dos métodos de a serem testados.
     * @return Checagem ok.
     */
    private Pair<StringBuilder, Boolean> checkTestMethods(
        final Class<?> classTest, final Map<String, Integer> methodTestCount,
        final Map<String, Integer> methodClassCount) {
        String testName;
        Integer countClass;
        Integer countTest;
        boolean problemFound = false;

        //checa se classe testada é genérica
        StringBuilder reportBuffer = new StringBuilder();
        boolean isClassGeneric = Reflections.isClassGeneric(classTest);

        if (isClassGeneric) {
            logger.info(
                "Generic class detected.\nOnly one test will be expected per " +
                "method name.\n");
        }

        // verifica se todos métodos da classe a ser testada são de fato testados
        // na classe de teste
        Iterator<Entry<String, Integer>> itClass = methodClassCount.entrySet()
                                                                   .iterator();
        Entry<String, Integer> classEntry;

        while (itClass.hasNext()) {
            classEntry = itClass.next();

            // monta nome esperado do método de teste
            testName = "test" +
                Strings.toUpperCaseFirstLetter(classEntry.getKey()).trim();

            // obtém e ajusta número de métodos
            countClass = classEntry.getValue();
            countTest = methodTestCount.get(testName);

            if (countTest == null) {
                countTest = 0;
            }

            if (isClassGeneric) {
                countClass = 1;
            }

            // verifica correspondência de métodos e monta relatório
            if (countClass.intValue() > countTest.intValue()) {
                reportBuffer.append("MISS");
                problemFound = true;
            } else {
                reportBuffer.append("OK");
            }

            reportBuffer.append(getReportRow(classEntry.getKey(), countClass,
                    countTest));
        }

        return new Pair<StringBuilder, Boolean>(reportBuffer, problemFound);
    }

    /**
     * @param methodName Nome do método.
     * @param times Total de execuções.
     * @param current Número corrente de execuções.
     * @return Log de execução do método de teste.
     */
    String getLogMethodExecution(final String methodName, final int times,
        final int current, final Integer order) {
        String msg = "Running ";

        if (times > 1) {
            msg += (current + " of " + times + " ");
        }

        if (order != null) {
            msg += ("in the order " + order + " ");
        }

        msg += (testClass.getSimpleName() + "." + methodName + " ...");

        return msg;
    }
}
